You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
add a shared versioned AES-256-GCM envelope for content-bearing secondary IndexedDB records
encrypt scene revisions, AI inference results, ProForge memory/history, cross-project metadata/embeddings, and LoRA metadata/datasets/training runs
fail closed while configured storage is locked, reject corrupt envelopes, and lazily migrate legacy plaintext after unlock
document the accepted DuckDB structural-metadata and large LoRA weight-blob exceptions
correct project documentation so cross-database passphrase rotation is tracked as a durable resumable follow-up, not described as atomic
Verification
pnpm run lint
pnpm run typecheck
pnpm run i18n:check (19 locales, 2,869 keys)
pnpm run parity:check
pnpm run docs:check
pnpm run suppressions:check (52/52)
pnpm run token:audit (160/160)
targeted Vitest: 109 existing/new secondary-store tests from the first commit
targeted Vitest: 53 Cross-Project/LoRA tests from the second commit
Security boundary
Large LoRA weight blobs remain outside the manuscript-data guarantee. DuckDB structural analytics metadata remains an accepted plaintext boundary; literal codex_mentions.excerpt encryption is unchanged. Durable resumable passphrase rotation across independent databases is intentionally deferred to the next isolated security change.
Note
High Risk
Touches encryption, key lifecycle, and many independent IndexedDB databases; mis-handling locked state, rotation, or disable could strand or expose sensitive manuscript-related data.
Overview
Extends at-rest encryption beyond primary project blobs to content-bearing secondary IndexedDB databases using a shared versioned AES-256-GCM envelope (prepareSecureRecordPayload / readSecureRecordPayload), record-bound AAD, and a structured codec that preserves binary payloads (e.g. Blobs).
Encrypted surfaces include scene revisions, AI inference cache, ProForge memory and run history, cross-project search metadata/embeddings, and LoRA adapter metadata, datasets, and training runs (routing/index fields stay plaintext; large LoRA weight blobs remain outside the guarantee). Reads/writes fail closed when encryption is configured but locked (SecureRecordLockedError / SecureRecordCorruptError); legacy plaintext migrates lazily after unlock. Settings passphrase change/disable now runs bulk re-encrypt or decrypt-to-plaintext across secondary stores via secondaryStorageMigration.ts.
Docs and release notes are updated to describe the new boundary and to state that cross-database passphrase rotation is not atomic—a durable resumable journal remains follow-up work. DeepSource gets cyclomatic_complexity_threshold = "critical"; focused Vitest coverage and a maintainer script for resolving DeepSource review threads are added.
Reviewed by Cursor Bugbot for commit 27177ce. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
Security
Expanded at-rest encryption for content-bearing browser storage, including caches, project indexes, adapters, training data, memories, history, and scene revisions.
Locked or corrupted encrypted records now fail closed.
Legacy plaintext records migrate automatically after unlocking.
Large binary weights and certain database metadata remain documented exceptions.
Documentation
Updated encryption guidance, threat-model coverage, changelog details, and release tracking.
CodeAnt-AI Description
Encrypt sensitive secondary IndexedDB data and fail closed while storage is locked
What Changed
Scene revisions, AI inference results, ProForge memory and run history, cross-project search details, and LoRA metadata, datasets, and training records are encrypted at rest when storage encryption is enabled.
Locked configured storage rejects reads and writes instead of returning or saving plaintext; damaged or unsupported encrypted records are rejected.
Existing plaintext records remain readable after unlock and are rewritten as encrypted records automatically.
Routing and indexing fields remain available for database lookups, while large LoRA weight blobs and approved DuckDB structural metadata remain documented exceptions.
Added coverage for encrypted round trips, locked access, corruption handling, and legacy migration, plus updated security documentation and release notes.
Impact
✅ Sensitive secondary data stays unreadable in extracted IndexedDB files ✅ No plaintext fallback while storage is locked ✅ Legacy records migrate without requiring a manual export
💡 Usage Guide
Checking Your Pull Request
Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.
Talking to CodeAnt AI
Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:
@codeant-ai ask: Your question here
This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.
Example
@codeant-ai ask: Can you suggest a safer alternative to storing this secret?
Preserve Org Learnings with CodeAnt
You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:
@codeant-ai: Your feedback here
This helps CodeAnt AI learn and adapt to your team's coding style and standards.
Example
@codeant-ai: Do not flag unused imports.
Retrigger review
Ask CodeAnt AI to review the PR again, by typing:
@codeant-ai: review
Check Your Repository Health
To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.
Settings now coordinates secondary-store decryption and re-encryption. Documentation records the encryption scope, exceptions, lifecycle, and non-atomic rotation limits.
We reviewed changes in 804793a...27177ce on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.
Some issues found as part of this review are outside of the diff in this pull request and aren't shown in the inline review comments due to GitHub's API limitations. You can see those issues on the DeepSource dashboard.
AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.
The reason will be displayed to describe this comment to others. Learn more.
Gate destructive writes while the encryption key is locked
When a user locks the session and then deletes a LoRA adapter, scene revision, project index, or memory entry, deleteAdapter, deleteRevision, removeProjectIndex, and deleteMemoryEntry issue raw IndexedDB deletes without passing through this configured-storage check, so destructive writes still succeed while the secondary stores are supposedly locked. Apply the same sentinel/key guard before these delete transactions to prevent irreversible data loss while locked.
The reason will be displayed to describe this comment to others. Learn more.
Treat partial secure envelopes as corruption
If an encrypted payload loses any one envelope field—for example, a missing ciphertext property—this predicate returns false. While the key is unlocked, readSecureRecordPayload consequently accepts the malformed object as legacy plaintext, and several callers spread and lazily rewrite it, potentially returning records without their required content and persisting that damage instead of raising SecureRecordCorruptError. Treat the presence of the version marker or any envelope field as a candidate, then validate the complete shape.
The reason will be displayed to describe this comment to others. Learn more.
Decrypt secondary records before disabling encryption
After a user enables encryption and writes any of these new secondary envelopes, the existing Settings disable flow verifies the passphrase and then calls clearIdbPassphrase(), which removes both the sentinel and active key without decrypting these databases. Subsequent reads still enter this envelope branch and always throw SecureRecordLockedError, while the disabled feature flag prevents the startup unlock modal from appearing, so revisions, ProForge history, search metadata, and LoRA records become inaccessible through the normal UI. The disable operation must rewrite secondary envelopes to plaintext before clearing the key and sentinel.
The reason will be displayed to describe this comment to others. Learn more.
Bind encrypted payloads to their routing fields
Because encryption receives only the payload, the AES-GCM authentication tag is not bound to the plaintext record ID, project ID, scene ID, or timestamps stored alongside it. Swapping two valid payload envelopes in IndexedDB therefore decrypts successfully and silently associates one project's or scene's content with another record instead of reporting corruption. Pass canonical routing fields as AES-GCM additionalData during encryption and require the same data during decryption.
The reason will be displayed to describe this comment to others. Learn more.
Preserve Blob artifacts when encrypting run history
When an encrypted ProForge run includes the production stage, its agentOutput contains a ProductionManifest whose artifacts carry generated PDF, EPUB, and Markdown Blob values. prepareSecureRecordPayload serializes this entire history through JSON.stringify, which converts each Blob to {}; after reload the decrypted history therefore contains invalid artifact objects and the generated files are silently lost, whereas the previous IndexedDB structured-clone write preserved them. Encode Blob bytes and MIME types explicitly or use a serialization format that supports structured-clone values.
Handoff — paused to conserve the remaining weekly agent budget
Current head: 0898f6a4 (25 changed files). This PR is intentionally Draft and must not merge yet.
Completed
Shared versioned AES-256-GCM secondary-record envelope with typed locked/corrupt failures.
Scene revisions, AI inference cache, ProForge memory/history, cross-project metadata/embeddings, and LoRA metadata/datasets/training records integrated.
Legacy plaintext lazy migration and raw-IDB canary coverage added.
Security boundary/docs reconciled; DuckDB structural metadata and large LoRA weight blobs remain explicit exceptions.
Follow-up 0898f6a4 fixes malformed/incomplete envelope fall-through; 46/46 focused storage-encryption tests and lint are green.
Earlier local gates were green: lint, typecheck, i18n (19 × 2,869), parity, docs, suppressions 52/52, tokens 160/160; targeted store tests were green.
Release blocker
Copilot correctly identified that the live passphrase-change and encryption-disable flows do not migrate/decrypt the new secondary envelopes. Changing the passphrase replaces the active verifier/key after only primary app-data/snapshot migration; disabling deletes the verifier. Either operation can strand secondary records under the old key. See #310 (comment).
Before marking ready, choose and test one safe path:
Preferred: implement the durable resumable cross-database rotation journal already specified in docs/IDB-ENCRYPTION.md, and make disable decrypt/migrate every registered secondary store before removing the verifier; or
Minimal interim safety: block passphrase change and disable in both UI and service-level APIs while secondary encryption is present, with honest user-facing copy, until the journal ships.
Also test interruption/restart, wrong old/new passphrase, mixed old/new records, failure before verifier replacement, and disable recovery. Do not merely hide UI controls while callable service paths remain destructive.
Reviews / CI
CodeRabbit auto-triggered but was rate-limited (no code review); retry after the cooldown shown by its PR note.
Copilot produced two findings: the rotation/disable blocker above remains open; incomplete-envelope fall-through is fixed by 0898f6a4 and its thread is now outdated.
DeepSource generated ~126 mostly identical Unexpected function declaration in the global scope findings against normal TypeScript ES modules, plus minor complexity notices. Treat the module-scope reports as analyzer/configuration false positives; do not wrap the codebase in IIFEs. They still need evidence replies/resolution when work resumes.
Cloud CI restarted for 0898f6a4 and was still pending at pause time. Recheck every required job and all paginated review threads before ready/merge.
Resume order
Resolve the passphrase rotation/disable blocker with tests.
Run only targeted local Vitest plus sequential quick gates.
Push; inspect CI artifacts and all 128+ paginated review threads.
Reply/resolve each valid or false-positive thread with evidence; retry CodeRabbit after its cooldown and repeat until a fresh review yields zero new comments.
Mark ready only when CI is green and unresolved thread count is zero. Merge via the already authorized admin bypass only then.
Do not cut a release from this draft. A future v1.27.0 boundary is appropriate only after rotation and durable-log hardening are complete.
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.
The reason will be displayed to describe this comment to others. Learn more.
Locked cache bypasses memory
Medium Severity
After session lock, getCachedInference still returns hits from the in-memory LRU before any configured-encryption check. IndexedDB reads fail closed with SecureRecordLockedError, but cached AI results remain readable in the same service instance until eviction or a new instance.
Spread importOriginal in crossProjectIndexDuckDb.test.ts so idbCore/dbMigration
receive full store constants when storageEncryptionService loads on import.
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: Lazy migration writes are based on the earlier raw snapshot. If updateAdapterMeta, saveAdapter, or another tab updates a record after the snapshot is read but before this migration transaction runs, this put overwrites the newer encrypted record with stale metadata, causing a lost update. Recheck the record or perform migration conditionally within a transaction. [race condition]
Severity Level: Major ⚠️
- ⚠️ Adapter metadata updates can be silently lost.
- ⚠️ LoRA library state may revert after migration.
- ⚠️ Affects legacy records during first unlocked access.
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖
This is a comment left during a code review.
**Path:** services/loraAdapterService.ts
**Line:** 195:198
**Comment:***Race Condition: Lazy migration writes are based on the earlier `raw` snapshot. If `updateAdapterMeta`, `saveAdapter`, or another tab updates a record after the snapshot is read but before this migration transaction runs, this `put` overwrites the newer encrypted record with stale metadata, causing a lost update. Recheck the record or perform migration conditionally within a transaction.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: The activation transaction writes all metadata generated from a stale snapshot taken before the asynchronous encryption work. A concurrent metadata update or save can commit first and then be silently overwritten by these older values, even though only the active-adapter marker needed to change. Avoid rewriting unchanged metadata or make the read-and-update operation conditional/transactionally serialized. [race condition]
Severity Level: Major ⚠️
- ⚠️ Activation can revert concurrent adapter edits.
- ⚠️ Metadata changes may disappear from the LoRA library.
- ⚠️ Affects activation and deactivation workflows.
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖
This is a comment left during a code review.
**Path:** services/loraAdapterService.ts
**Line:** 269:270
**Comment:***Race Condition: The activation transaction writes all metadata generated from a stale snapshot taken before the asynchronous encryption work. A concurrent metadata update or save can commit first and then be silently overwritten by these older values, even though only the active-adapter marker needed to change. Avoid rewriting unchanged metadata or make the read-and-update operation conditional/transactionally serialized.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
Duplicated raw IndexedDB test helpers leave connections open. Both new test files add their own open/getAll/put wrappers over raw IndexedDB and never close the returned IDBDatabase. One shared helper removes the duplication and gives a single place to close handles.
tests/unit/crossProjectIndexService.test.ts#L48-L76: replace readRawIndex and writeRawIndex with calls into the shared helper and close each connection after use.
tests/unit/aiInferenceCacheEncryption.test.ts#L14-L42: replace readRawEntries and writeRawEntry with the same shared helper, and import the database and store names from the cache service instead of redeclaring DB_NAME and STORE.
As per coding guidelines: "Apply DRY: place reusable logic in services, hooks, or feature thunks instead of duplicating it in views."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/unit/crossProjectIndexService.test.ts` around lines 48 - 76, Replace
the duplicated raw IndexedDB helpers in
tests/unit/crossProjectIndexService.test.ts#L48-L76 and
tests/unit/aiInferenceCacheEncryption.test.ts#L14-L42 with one shared test
helper that opens, operates on, and closes the database connection; update both
test files to use it, and in
tests/unit/aiInferenceCacheEncryption.test.ts#L14-L42 import the database and
store names from the cache service instead of redeclaring DB_NAME and STORE.
Add QNBS-v3 why-comments for the new encode and migrate helpers.
encodeProjectSearchIndex and the migration write inside enrichProjectIndex are non-trivial changes without a QNBS-v3 comment. indexProject at line 176 and listIndexedProjects at line 217 already carry one. Add one single-line comment for each new behaviour, in particular why enrichProjectIndex persists a migrated record at line 267 before the enriched write at line 292.
As per coding guidelines: "For every non-trivial code change, add one single-line QNBS-v3 comment explaining why, using the appropriate TS/JS, JSX, or CSS syntax; never wrap the comment across physical lines."
Also applies to: 246-268
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/crossProjectIndexService.ts` around lines 88 - 97, Add single-line
QNBS-v3 comments explaining the rationale for the new behavior in
encodeProjectSearchIndex and the migration write within enrichProjectIndex. In
particular, document why the migrated record is persisted before the later
enriched write; keep each comment on one physical line and use valid TypeScript
comment syntax.
One corrupt record makes the whole project list unreadable.
listIndexedProjects decodes sequentially and lets the first SecureRecordCorruptError reject the whole call. Cross-project search then fails completely, even when only one project record is damaged. Fail-closed is correct for locked storage, because the key is missing for every record. A single corrupt record is different: the remaining records are still recoverable.
Consider collecting corrupt projectId values, logging them through services/logger.ts with warn, and returning the decodable records. Keep the locked error propagating.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/crossProjectIndexService.ts` around lines 205 - 226, Update
listIndexedProjects to handle SecureRecordCorruptError per record: collect the
affected projectId values, log them with the services/logger.ts warn facility,
and continue decoding the remaining records. Preserve propagation of
locked-storage/key errors, and keep migration and sorting behavior for
successfully decoded records unchanged.
Note the divergence between memory and persistent cache on migration and eviction.
When this.db is null the entry only enters memory, and when encryption is locked nothing is cached at all. Both paths are intentional. One residual gap: the in-memory map keeps the plaintext result after a later clearIdbEncryptionKey() in the same session, so a locked state still serves cached results from memory. If the lock is meant to stop all reads, clear inMemory when the key is cleared.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/ai/aiInferenceCacheService.ts` around lines 186 - 197, Update
clearIdbEncryptionKey to clear the inMemory cache when the encryption key is
cleared, ensuring subsequent locked-state reads cannot serve previously cached
plaintext results while preserving the existing persistent-cache handling.
Share the store constants instead of duplicating the literals.
DB_NAME and STORE repeat values owned by services/ai/aiInferenceCacheService.ts. If the service renames its database or store, readRawEntries opens a fresh empty database and the assertions fail with a confusing "expected defined" message rather than a rename signal. Export the constants from the service module and import them here.
The two helpers also leave each opened IDBDatabase open. Call db.close() after the read or write so a version change in a later test is not blocked.
Also applies to: 17-42
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/unit/aiInferenceCacheEncryption.test.ts` around lines 14 - 15, Export
the database and store constants from aiInferenceCacheService and import them
into the tests instead of redeclaring literals in
aiInferenceCacheEncryption.test.ts. Update both helper functions, readRawEntries
and the write helper, to call db.close() after completing their IndexedDB read
or write operations, including the relevant cleanup path.
Add the required single-line QNBS-v3 why-comments.
services/storage/storageEncryptionService.ts#L36-L68: add one comment that explains the versioned envelope and fail-closed storage contract.
services/storage/idbPassphraseSentinel.ts#L46-L51: add one comment that explains why test resets must close cached IDB handles.
tests/unit/storage/storageEncryptionService.test.ts#L177-L267: add one comment that explains the secure-record regression coverage.
As per coding guidelines, “For every non-trivial code change, add one single-line QNBS-v3 comment explaining why.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/storage/storageEncryptionService.ts` around lines 36 - 68, Add one
single-line QNBS-v3 why-comment in services/storage/storageEncryptionService.ts
near SecureRecordEnvelope and the related errors, explaining the versioned
envelope and fail-closed storage contract. Add one single-line QNBS-v3
why-comment in services/storage/idbPassphraseSentinel.ts near the test-reset
logic, explaining why cached IndexedDB handles must be closed. Add one
single-line QNBS-v3 why-comment in
tests/unit/storage/storageEncryptionService.test.ts covering the secure-record
regression tests, explaining their purpose.
Both branches build the same SceneRevision from id, sectionId, createdAt, and the decoded payload. Only the payload source differs. Select the payload source first, then assemble once.
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/sceneRevisionService.ts` around lines 82 - 109, Refactor
decodeRevision to select the payload source based on
isStoredSceneRevision(stored), preserving the stored and legacy payload paths,
then call readSecureRecordPayload once and assemble the SceneRevision once from
the appropriate record’s id, sectionId, createdAt, and decoded value. Preserve
the existing needsMigration result.
Remove the duplicated entry-assembly in both branches.
Both branches build the same MemoryBankEntry shape from id, projectId, category, createdAt, and the decoded payload. Only the payload source differs. Extract the payload source first, then assemble once.
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/proForge/proForgeMemoryBank.ts` around lines 106 - 134, Refactor
decodeMemoryEntry so it selects the payload source first—stored.payload for
records containing payload, otherwise memoryPayload(stored)—and calls
readSecureRecordPayload once. Assemble the MemoryBankEntry and return
needsMigration once from the shared decoded result, preserving the existing
fields and migration behavior.
Lazy-migration writes are awaited inside the read path in all three stores. Each read decodes the payload successfully and then awaits a rewrite of the migrated record. If the migration transaction fails, the read rejects and the caller loses data it could already display. Decouple the migration write from the read result in each store.
services/proForge/proForgeHistoryStore.ts#L106-L108: wrap the putHistory migration call in a try/catch, log the failure through services/logger.ts, and still return decoded.value.runs.
services/proForge/proForgeMemoryBank.ts#L196-L201: wrap the putMemoryEntries(db, migrations) call in a try/catch, log the failure, and still return entries.
services/sceneRevisionService.ts#L177-L183: wrap the putStoredRevisions(db, migrations) call in a try/catch, log the failure, and still return the sorted revisions.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/proForge/proForgeHistoryStore.ts` around lines 106 - 108, Decouple
lazy-migration writes from successful reads by wrapping the migration
persistence calls in try/catch blocks and logging failures through
services/logger.ts. In services/proForge/proForgeHistoryStore.ts lines 106-108,
preserve returning decoded.value.runs; in
services/proForge/proForgeMemoryBank.ts lines 196-201, preserve returning
entries; and in services/sceneRevisionService.ts lines 177-183, preserve
returning the sorted revisions even when putHistory, putMemoryEntries, or
putStoredRevisions fails.
Close the raw IndexedDB connections in the test helpers.
readRawHistory and writeRawHistory open a new connection on every call and never close it. Open connections block versionchange and database deletion, so a later test can hang or fail if the schema changes. Close the connection after the request settles. The same pattern exists in tests/unit/proForge/proForgeMemoryBank.test.ts and tests/unit/sceneRevisionService.test.ts.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/unit/proForge/proForgeHistoryStore.test.ts` around lines 25 - 50,
Update the readRawHistory and writeRawHistory test helpers to close each
IndexedDB connection after its read or write transaction settles, including
error paths. Apply the same connection-cleanup pattern to the corresponding raw
IndexedDB helpers in proForgeMemoryBank.test.ts and
sceneRevisionService.test.ts.
Do not pin the raw helpers to database version 2, and close the connections.
readRaw and writeRaw hardcode version 2. The service owns DB_VERSION. If that constant increases, both helpers request a lower version and fail with VersionError, which produces a confusing test failure instead of a schema signal. Omit the version so the helpers open the current database.
Both helpers also leave the connection open. A leaked connection blocks a later versionchange upgrade inside the same test.
♻️ Proposed fix
async function readRaw(
storeName: string,
key: string,
): Promise<Record<string, unknown> | undefined> {
+ // QNBS-v3: Open without a version so the helper tracks the service schema instead of pinning it.
const db = await new Promise<IDBDatabase>((resolve, reject) => {
- const request = indexedDB.open(DB_NAME, 2);+ const request = indexedDB.open(DB_NAME);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
- return new Promise((resolve, reject) => {+ const result = await new Promise<Record<string, unknown> | undefined>((resolve, reject) => {
const request = db.transaction(storeName, 'readonly').objectStore(storeName).get(key);
request.onsuccess = () => resolve(request.result as Record<string, unknown> | undefined);
request.onerror = () => reject(request.error);
});
+ db.close();+ return result;
}
async function writeRaw(storeName: string, record: Record<string, unknown>): Promise<void> {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
- const request = indexedDB.open(DB_NAME, 2);+ const request = indexedDB.open(DB_NAME);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite');
transaction.objectStore(storeName).put(record);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
+ db.close();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/unit/lora/loraAdapterEncryption.test.ts` around lines 76 - 104, Update
the IndexedDB opening logic in readRaw and writeRaw to omit the hardcoded
version argument, allowing the current database version to be used, and close
each resolved IDBDatabase connection after its read or write operation completes
or fails.
Collapse the two branches like decodeDatasetEntry does.
Both branches build the same object. decodeDatasetEntry (Line 426) and decodeTrainingRun (Line 553) already select the payload inline. Use the same shape here for consistency.
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/loraAdapterService.ts` around lines 129 - 155, Collapse the
duplicated branches in decodeAdapterMeta by selecting the secure payload inline,
using stored.payload when the stored value contains it and
adapterMetaPayload(stored) otherwise. Perform a single readSecureRecordPayload
call and retain the existing meta fields and needsMigration result.
Also clear the cache when the connection closes unexpectedly.
onversionchange resets dbHandle and dbPromise. An unexpected close (browser eviction, forced close) fires onclose instead. In that case the cached handle stays, and every later transaction throws InvalidStateError for the lifetime of the page.
♻️ Proposed fix
dbHandle.onversionchange = () => {
dbHandle?.close();
dbHandle = null;
dbPromise = null;
};
+ // QNBS-v3: Drop the cached handle on unexpected close so later calls can reopen the database.+ dbHandle.onclose = () => {+ dbHandle = null;+ dbPromise = null;+ };
resolve(dbHandle);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/loraAdapterService.ts` around lines 86 - 101, Update the database
connection setup in the request success handler to assign an onclose callback
alongside onversionchange. When the connection closes unexpectedly, close or
clear the cached dbHandle and reset dbPromise so subsequent access reopens the
database instead of reusing an invalid handle.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/IDB-ENCRYPTION.md`:
- Line 5: Update the documented default for enableIdbAtRestEncryption in the
feature-flag description from enabled to disabled, keeping the repository’s
default-flag rule and implementation unchanged.
- Around line 113-124: Prevent passphrase rotation and encryption-disable
operations from proceeding until every encrypted store, including secondary
secure-record databases, is handled atomically. Update rotateIdbPassphrase and
the corresponding UI/service disable flow to either use the required durable
journal with resumable checkpoints and verifier preservation or reject the
operations explicitly; ensure interrupted and failed migrations retain
recoverable data and require both passphrases to resume, and add coverage for
interruption, restart, partial migration, verifier failure, wrong passphrase,
and disable recovery.
In `@services/crossProjectIndexService.ts`:
- Around line 99-123: Update decodeProjectSearchIndex to validate
stored.schemaVersion against the supported schema and narrowly validate
decoded.value before spreading it into ProjectSearchIndex, including required
fields such as characterNames. Throw SecureRecordCorruptError for unknown schema
versions or invalid payload shapes, and add the missing import; preserve the
existing migration metadata for valid records.
In `@services/loraAdapterService.ts`:
- Around line 190-203: Isolate best-effort migration writes so failures do not
discard successfully decoded results. In services/loraAdapterService.ts lines
190-203, 463-476, and 590-603, add or reuse a shared helper that catches and
logs migration write errors, then use it for the META_STORE, DATASETS_STORE, and
RUNS_STORE putRecords calls so listAdapters, listDatasetEntries, and
listTrainingRuns still return their decoded collections.
In `@services/sceneRevisionService.ts`:
- Around line 146-158: Update saveRevision’s eviction path to read raw
StoredSceneRevision records for the section instead of calling listRevisions,
using only plaintext id and createdAt fields; sort those records by createdAt
and delete the entries beyond MAX_PER_SCENE. Keep decryption out of this path so
eviction is independent of revision content integrity and does not decrypt full
scene data.
In `@services/storage/storageEncryptionService.ts`:
- Around line 245-284: Add a durable, resumable secondary-store migration
journal and make rotateIdbPassphrase and clearIdbPassphrase complete or block
until migration finishes. Update prepareSecureRecordPayload and
readSecureRecordPayload to support interruption-safe progress across restarts,
mixed records, wrong passphrases, and recovery before disabling encryption; add
tests covering these cases.
---
Nitpick comments:
In `@services/ai/aiInferenceCacheService.ts`:
- Around line 186-197: Update clearIdbEncryptionKey to clear the inMemory cache
when the encryption key is cleared, ensuring subsequent locked-state reads
cannot serve previously cached plaintext results while preserving the existing
persistent-cache handling.
In `@services/crossProjectIndexService.ts`:
- Around line 88-97: Add single-line QNBS-v3 comments explaining the rationale
for the new behavior in encodeProjectSearchIndex and the migration write within
enrichProjectIndex. In particular, document why the migrated record is persisted
before the later enriched write; keep each comment on one physical line and use
valid TypeScript comment syntax.
- Around line 205-226: Update listIndexedProjects to handle
SecureRecordCorruptError per record: collect the affected projectId values, log
them with the services/logger.ts warn facility, and continue decoding the
remaining records. Preserve propagation of locked-storage/key errors, and keep
migration and sorting behavior for successfully decoded records unchanged.
In `@services/loraAdapterService.ts`:
- Around line 129-155: Collapse the duplicated branches in decodeAdapterMeta by
selecting the secure payload inline, using stored.payload when the stored value
contains it and adapterMetaPayload(stored) otherwise. Perform a single
readSecureRecordPayload call and retain the existing meta fields and
needsMigration result.
- Around line 86-101: Update the database connection setup in the request
success handler to assign an onclose callback alongside onversionchange. When
the connection closes unexpectedly, close or clear the cached dbHandle and reset
dbPromise so subsequent access reopens the database instead of reusing an
invalid handle.
In `@services/proForge/proForgeHistoryStore.ts`:
- Around line 106-108: Decouple lazy-migration writes from successful reads by
wrapping the migration persistence calls in try/catch blocks and logging
failures through services/logger.ts. In
services/proForge/proForgeHistoryStore.ts lines 106-108, preserve returning
decoded.value.runs; in services/proForge/proForgeMemoryBank.ts lines 196-201,
preserve returning entries; and in services/sceneRevisionService.ts lines
177-183, preserve returning the sorted revisions even when putHistory,
putMemoryEntries, or putStoredRevisions fails.
In `@services/proForge/proForgeMemoryBank.ts`:
- Around line 106-134: Refactor decodeMemoryEntry so it selects the payload
source first—stored.payload for records containing payload, otherwise
memoryPayload(stored)—and calls readSecureRecordPayload once. Assemble the
MemoryBankEntry and return needsMigration once from the shared decoded result,
preserving the existing fields and migration behavior.
In `@services/sceneRevisionService.ts`:
- Around line 82-109: Refactor decodeRevision to select the payload source based
on isStoredSceneRevision(stored), preserving the stored and legacy payload
paths, then call readSecureRecordPayload once and assemble the SceneRevision
once from the appropriate record’s id, sectionId, createdAt, and decoded value.
Preserve the existing needsMigration result.
In `@services/storage/storageEncryptionService.ts`:
- Around line 36-68: Add one single-line QNBS-v3 why-comment in
services/storage/storageEncryptionService.ts near SecureRecordEnvelope and the
related errors, explaining the versioned envelope and fail-closed storage
contract. Add one single-line QNBS-v3 why-comment in
services/storage/idbPassphraseSentinel.ts near the test-reset logic, explaining
why cached IndexedDB handles must be closed. Add one single-line QNBS-v3
why-comment in tests/unit/storage/storageEncryptionService.test.ts covering the
secure-record regression tests, explaining their purpose.
In `@tests/unit/aiInferenceCacheEncryption.test.ts`:
- Around line 14-15: Export the database and store constants from
aiInferenceCacheService and import them into the tests instead of redeclaring
literals in aiInferenceCacheEncryption.test.ts. Update both helper functions,
readRawEntries and the write helper, to call db.close() after completing their
IndexedDB read or write operations, including the relevant cleanup path.
In `@tests/unit/crossProjectIndexService.test.ts`:
- Around line 48-76: Replace the duplicated raw IndexedDB helpers in
tests/unit/crossProjectIndexService.test.ts#L48-L76 and
tests/unit/aiInferenceCacheEncryption.test.ts#L14-L42 with one shared test
helper that opens, operates on, and closes the database connection; update both
test files to use it, and in
tests/unit/aiInferenceCacheEncryption.test.ts#L14-L42 import the database and
store names from the cache service instead of redeclaring DB_NAME and STORE.
In `@tests/unit/lora/loraAdapterEncryption.test.ts`:
- Around line 76-104: Update the IndexedDB opening logic in readRaw and writeRaw
to omit the hardcoded version argument, allowing the current database version to
be used, and close each resolved IDBDatabase connection after its read or write
operation completes or fails.
In `@tests/unit/proForge/proForgeHistoryStore.test.ts`:
- Around line 25-50: Update the readRawHistory and writeRawHistory test helpers
to close each IndexedDB connection after its read or write transaction settles,
including error paths. Apply the same connection-cleanup pattern to the
corresponding raw IndexedDB helpers in proForgeMemoryBank.test.ts and
sceneRevisionService.test.ts.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
Push a commit to this branch (recommended)
Create a new PR with the fixes
ℹ️ Review info⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8f62fa24-a3b5-4628-8794-693e40824860
📥 Commits
Reviewing files that changed from the base of the PR and between 804793a and 0898f6a.
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Align the documented feature-flag default.
enableIdbAtRestEncryption is documented as enabled by default, but the repository rule enables only enableCodexAutoTracking, enableCrossProjectSearch, and enablePlotBoardV2 by default. All other flags must be disabled by default. Confirm the implementation and change this line to off by default, or update the rule and every affected default.
Before → After: on by default → off by default.
As per coding guidelines, only enableCodexAutoTracking, enableCrossProjectSearch, and enablePlotBoardV2 are enabled by default; all other flags are disabled by default.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/IDB-ENCRYPTION.md` at line 5, Update the documented default for
enableIdbAtRestEncryption in the feature-flag description from enabled to
disabled, keeping the repository’s default-flag rule and implementation
unchanged.
set -eu
printf'%s\n''--- relevant tracked files ---'
git ls-files | rg -i '(^|/)(docs/IDB-ENCRYPTION\.md|.*(dbService|settings|passphrase|encrypt|secure|rotation).*)$'| head -200
printf'%s\n''--- documentation ---'
sed -n '100,150p' docs/IDB-ENCRYPTION.md
printf'%s\n''--- relevant references ---'
rg -n -i --glob '!node_modules/**' --glob '!dist/**''passphrase|rotate|rotation|verifier|disable.*encrypt|encryption.*disable|secondary|journal'.| head -300
Repository: qnbs/WorldScript-Studio
Length of output: 50380
🏁 Script executed:
set -eu
printf'%s\n''--- rotation call sites ---'
rg -n -C 12 'rotateIdbPassphrase|clearIdbPassphrase|setupIdbEncryption|handlePassphraseConfirm' hooks/useSettingsView.ts components/settings/PassphraseModal.tsx components/settings/PrivacySection.tsx services/storage services/dbService.ts
printf'%s\n''--- encryption service outline and implementation ---'
ast-grep outline services/storage/storageEncryptionService.ts
sed -n '1,280p' services/storage/storageEncryptionService.ts
printf'%s\n''--- sentinel implementation ---'
cat -n services/storage/idbPassphraseSentinel.ts
printf'%s\n''--- document and policy references ---'
sed -n '350,380p' AGENTS.md
sed -n '510,550p' AUDIT.md
Repository: qnbs/WorldScript-Studio
Length of output: 41296
🏁 Script executed:
set -eu
printf'%s\n''--- exact Settings handlers ---'
sed -n '360,415p' hooks/useSettingsView.ts
printf'%s\n''--- exact service exports and rotation body ---'
rg -n -C 8 'export (async )?function|rotateIdbPassphrase|clearIdbPassphrase|setupIdbEncryption|disable|setIdb|sentinel' services/storage/storageEncryptionService.ts
printf'%s\n''--- secondary store registration and migration hooks ---'
rg -n -C 5 'register|secondary|rotate|reEncrypt|oldKey|newKey|encrypt|decrypt' services/storage services/sceneRevisionService.ts services/ai/aiInferenceCacheService.ts services/proForge services/crossProjectIndexService.ts services/loraAdapterService.ts
Repository: qnbs/WorldScript-Studio
Length of output: 50379
🏁 Script executed:
set -eu
printf'%s\n''--- service source ---'
wc -l services/storage/storageEncryptionService.ts services/storage/idbPassphraseSentinel.ts
sed -n '1,260p' services/storage/storageEncryptionService.ts
printf'%s\n''--- settings source ---'
sed -n '368,408p' hooks/useSettingsView.ts
printf'%s\n''--- relevant tests ---'
rg -n -C 5 'rotateIdbPassphrase|clearIdbPassphrase|disable|secondary|snapshot|re-encrypt|passphrase' tests/unit/storage tests/unit/storageEncryptionService.test.ts tests/unit/settings/PassphraseModal.test.tsx
Repository: qnbs/WorldScript-Studio
Length of output: 50379
🏁 Script executed:
python3 - <<'PY'from pathlib import Pathhooks = Path("hooks/useSettingsView.ts").read_text()service = Path("services/storage/storageEncryptionService.ts").read_text()rotation = hooks[hooks.index("await rotateIdbPassphrase"):hooks.index("setEncryptionReady(true);", hooks.index("await rotateIdbPassphrase"))]disable = hooks[hooks.index("} else if (passphraseModal === 'disable')"):hooks.index("} else if (passphraseModal === 'unlock')") if "} else if (passphraseModal === 'unlock')" in hooks[hooks.index("} else if (passphraseModal === 'disable')"):] else hooks.index(" }", hooks.index("} else if (passphraseModal === 'disable')"))]print("rotation_calls:", [name for name in ("reEncryptAllAppData", "reEncryptAllSnapshots") if name in rotation])print("rotation_updates_sentinel_before_callback:", service.index("await setupIdbEncryption(newPassphrase)") < service.index("if (reEncrypt)", service.index("await setupIdbEncryption(newPassphrase)")))print("disable_order:", [ token for token in ("verifyAndInitIdbEncryption", "clearIdbPassphrase", "setEnableIdbAtRestEncryption(false)") if token in disable])print("clear_deletes_sentinel:", "await deletePassphraseSentinel()" in service)PY
Repository: qnbs/WorldScript-Studio
Length of output: 410
Block passphrase rotation and encryption disable until every encrypted store is handled.
rotateIdbPassphrase replaces the sentinel before migration completes, while the callback covers only app data and snapshots. Secondary records can remain encrypted with the old key. Disabling encryption deletes the sentinel and clears the active key without migrating those records. Implement the durable journal or reject both operations in the UI and service APIs. Add interruption, restart, partial-migration, verifier-failure, wrong-passphrase, and disable-recovery tests.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/IDB-ENCRYPTION.md` around lines 113 - 124, Prevent passphrase rotation
and encryption-disable operations from proceeding until every encrypted store,
including secondary secure-record databases, is handled atomically. Update
rotateIdbPassphrase and the corresponding UI/service disable flow to either use
the required durable journal with resumable checkpoints and verifier
preservation or reject the operations explicitly; ensure interrupted and failed
migrations retain recoverable data and require both passphrases to resume, and
add coverage for interruption, restart, partial migration, verifier failure,
wrong passphrase, and disable recovery.
Validate the decoded payload and schemaVersion before spreading it into ProjectSearchIndex.
decodeProjectSearchIndex spreads decoded.value without any shape check, and it ignores stored.schemaVersion. The sibling implementation in services/ai/aiInferenceCacheService.ts (line 125) rejects a decoded payload whose shape is wrong. Here a truncated or foreign record decodes into a ProjectSearchIndex with missing fields. enrichProjectIndex then calls record.characterNames.slice(0, 5) at line 271 and throws a TypeError instead of the typed SecureRecordCorruptError.
Add a narrow shape guard and reject unknown schema versions.
♻️ Proposed guard
+// QNBS-v3: Shape guard keeps corrupt or foreign records on the typed fail-closed path.+function isProjectSearchPayload(value: unknown): value is ProjectSearchPayload {+ if (typeof value !== 'object' || value === null) return false;+ const candidate = value as Record<string, unknown>;+ return (+ typeof candidate['title'] === 'string' &&+ typeof candidate['logline'] === 'string' &&+ typeof candidate['manuscriptWordCount'] === 'number' &&+ Array.isArray(candidate['characterNames'])+ );+}+
async function decodeProjectSearchIndex(
stored: StoredProjectSearchIndex | ProjectSearchIndex,
): Promise<{ record: ProjectSearchIndex; needsMigration: boolean }> {
if ('payload' in stored) {
+ if (stored.schemaVersion !== RECORD_SCHEMA_VERSION) throw new SecureRecordCorruptError();
const decoded = await readSecureRecordPayload<ProjectSearchPayload>(stored.payload);
+ if (!isProjectSearchPayload(decoded.value)) throw new SecureRecordCorruptError();
return {
The import list at lines 11-15 also needs SecureRecordCorruptError.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/crossProjectIndexService.ts` around lines 99 - 123, Update
decodeProjectSearchIndex to validate stored.schemaVersion against the supported
schema and narrowly validate decoded.value before spreading it into
ProjectSearchIndex, including required fields such as characterNames. Throw
SecureRecordCorruptError for unknown schema versions or invalid payload shapes,
and add the missing import; preserve the existing migration metadata for valid
records.
Best-effort migration writes can discard decoded read results. All three list functions call putRecords for lazy migration inside the same try that guards decoding. If the migration write fails, the outer catch swallows the error and the function returns an empty array, so callers see no records although every record decoded correctly. Wrap the migration write in its own try/catch that logs and continues.
services/loraAdapterService.ts#L190-L203: isolate the putRecords(db, META_STORE, migrations) call so listAdapters still returns adapters after a failed migration write.
services/loraAdapterService.ts#L463-L476: isolate the putRecords(db, DATASETS_STORE, migrations) call so listDatasetEntries still returns entries.
services/loraAdapterService.ts#L590-L603: isolate the putRecords(db, RUNS_STORE, migrations) call so listTrainingRuns still returns runs.
A shared helper keeps this to one change:
♻️ Proposed fix
// QNBS-v3: Lazy migration is best-effort; a failed rewrite must not discard decoded records.asyncfunctionputMigrations(db: IDBDatabase,storeName: string,records: Array<Record<string,unknown>|object>,): Promise<void>{try{awaitputRecords(db,storeName,records);}catch(err){logger.warn('loraAdapterService: migration write failed',{ storeName, err });}}
‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/loraAdapterService.ts` around lines 190 - 203, Isolate best-effort
migration writes so failures do not discard successfully decoded results. In
services/loraAdapterService.ts lines 190-203, 463-476, and 590-603, add or reuse
a shared helper that catches and logs migration write errors, then use it for
the META_STORE, DATASETS_STORE, and RUNS_STORE putRecords calls so listAdapters,
listDatasetEntries, and listTrainingRuns still return their decoded collections.
Do not decrypt every revision to perform eviction.
saveRevision calls listRevisions(sectionId) only to find the ids to delete. listRevisions now decrypts every stored revision of the section, so each save performs up to MAX_PER_SCENE + 1 AES-GCM decryptions of full scene content. Eviction needs only id and createdAt, and both stay in plaintext inside StoredSceneRevision.
The reuse also couples save to read integrity. If one older revision is corrupt, listRevisions rejects with SecureRecordCorruptError and saveRevision rejects after the new revision was already written.
Read the raw records for the section and sort by createdAt without decrypting.
⚡ Proposed fix
// Evict if over MAX_PER_SCENE
- const existing = await listRevisions(sectionId);+ const existing = await new Promise<Array<{ id: string; createdAt: number }>>(+ (resolve, reject) => {+ const tx = db.transaction(STORE, 'readonly');+ const req = tx.objectStore(STORE).index('sectionId').getAll(sectionId);+ req.onsuccess = () =>+ resolve(+ (req.result as Array<{ id: string; createdAt: number }>)+ .map((r) => ({ id: r.id, createdAt: r.createdAt }))+ .sort((a, b) => b.createdAt - a.createdAt),+ );+ req.onerror = () => reject(req.error);+ },+ );
if (existing.length > MAX_PER_SCENE) {
📝 Committable suggestion
‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/sceneRevisionService.ts` around lines 146 - 158, Update
saveRevision’s eviction path to read raw StoredSceneRevision records for the
section instead of calling listRevisions, using only plaintext id and createdAt
fields; sort those records by createdAt and delete the entries beyond
MAX_PER_SCENE. Keep decryption out of this path so eviction is independent of
revision content integrity and does not decrypt full scene data.
Complete secondary-store migration before rotating or disabling encryption.
rotateIdbPassphrase activates the new key before its callback completes. The callback migrates only app data and snapshots. Secondary stores remain encrypted with the old key and become unreadable after rotation. clearIdbPassphrase deletes the sentinel without decrypting these records, so disabling encryption also removes recovery.
Add a durable, resumable migration journal. Otherwise block rotation and disable flows. Add interruption, restart, mixed-record, wrong-passphrase, and disable-recovery tests.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/storage/storageEncryptionService.ts` around lines 245 - 284, Add a
durable, resumable secondary-store migration journal and make
rotateIdbPassphrase and clearIdbPassphrase complete or block until migration
finishes. Update prepareSecureRecordPayload and readSecureRecordPayload to
support interruption-safe progress across restarts, mixed records, wrong
passphrases, and recovery before disabling encryption; add tests covering these
cases.
The reason will be displayed to describe this comment to others. Learn more.
Rotation breaks unmigrated legacy records
High Severity
Passphrase rotation re-encrypt helpers only inspect each row’s nested payload field. Unmigrated legacy rows still keep sensitive fields at the top level (scene text, cache result, pipeline runs, etc.), so rotation encrypts an empty inner payload while legacy plaintext remains in IndexedDB and the record becomes unreadable through normal decode paths.
The reason will be displayed to describe this comment to others. Learn more.
Rotation skips legacy record shapes
High Severity
Passphrase rotation re-encrypt helpers only read payload or envelope fields and never use the same legacy decoding as reads and disable migrations. Unmigrated records still stored in pre-wrapper shapes get an encrypted empty or wrong payload while sensitive fields can remain on the row, so rotation can lose data and leave content readable in IndexedDB.
Secure-storage errors now break AI calls that only needed a cache lookup.
Line 190 forwards any rejection from decodeEntry to the caller. decodeEntry throws SecureRecordLockedError when storage is configured but locked, and SecureRecordCorruptError for one damaged record. getCachedInference therefore rejects instead of reporting a cache miss, and the AI request fails.
The surrounding code treats this layer as best effort: line 192 resolves null on an IndexedDB error, and the comment on line 148 states that persistence is best effort. Line 207 has the same problem on the write path, where a locked store makes setCachedInference reject and the caller loses the completed AI result.
Treat both directions as best effort and log through services/logger.ts.
🐛 Proposed fix
void (async () => {
const decoded = await this.decodeEntry(entry);
if (decoded.needsMigration) {
await this.persistEntry(await this.encodeEntry(key, decoded.result, entry.timestamp));
}
this.evictLru();
this.inMemory.set(key, { result: decoded.result, lastUsed: Date.now() });
return decoded.result;
- })().then(resolve, reject);+ })().then(resolve, (err: unknown) => {+ // QNBS-v3: A locked or corrupt cache record must degrade to a miss, not fail the AI call.+ logger.warn('aiInferenceCache: cache read unavailable', sanitizeLogContext({ err }));+ resolve(null);+ });
Apply the same treatment to the write path:
- // QNBS-v3: Encrypt before mutating either cache layer so a locked persistent cache fails atomically.- const entry = await this.encodeEntry(key, result, Date.now());+ // QNBS-v3: A locked persistent cache must not discard the caller's AI result.+ let entry: CacheEntry;+ try {+ entry = await this.encodeEntry(key, result, Date.now());+ } catch (err) {+ logger.warn('aiInferenceCache: skipping persistence', sanitizeLogContext({ err }));+ this.evictLru();+ this.inMemory.set(key, { result, lastUsed: Date.now() });+ return;+ }
reject then becomes unused in the getCachedInference promise executor.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/ai/aiInferenceCacheService.ts` around lines 182 - 192, Update the
cache read and write paths in getCachedInference and setCachedInference so
SecureRecordLockedError and SecureRecordCorruptError are treated as cache misses
or ignored persistence failures rather than propagated to AI callers. Catch
decodeEntry and persist/encode failures, log them through services/logger.ts,
resolve the read with null, and keep writes best effort; remove the now-unused
reject parameter from the getCachedInference promise executor.
The new operations can reject on locked or corrupt records and IndexedDB failures. handlePassphraseConfirm has no try/catch or Result path. A failure can escape without a brief, actionable translated error.
Catch the complete operation, keep the modal open, and update encryption state only after success.
As per coding guidelines, async operations must use try/catch or a Result type, and user-facing errors must be brief and actionable.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@hooks/useSettingsView.ts` around lines 389 - 404, Update
handlePassphraseConfirm to wrap the complete passphrase lifecycle operation,
including rotation, unlock, and disable flows, in try/catch. Keep the modal open
on failure, show a brief actionable translated error, and move
setEncryptionReady and success-toast updates so they execute only after the
corresponding async operations complete successfully.
decodeProjectSearchIndex still spreads the decoded payload without validation.
Line 114 and line 125 spread decoded.value straight into a ProjectSearchIndex, and stored.schemaVersion is never compared with RECORD_SCHEMA_VERSION. A truncated or foreign record therefore produces an object with missing fields instead of a typed failure. enrichProjectIndex then calls record.characterNames.slice(...) and throws a TypeError.
The sibling decoder in services/ai/aiInferenceCacheService.ts at line 139 rejects a payload whose shape is wrong. migrateCrossProjectIndexForDisable at line 407 also depends on this decoder, so an invalid record now becomes a persisted plaintext record.
🛡️ Proposed guard
+// QNBS-v3: Shape guard keeps corrupt or foreign records on the typed fail-closed path.+function isProjectSearchPayload(value: unknown): value is ProjectSearchPayload {+ if (typeof value !== 'object' || value === null) return false;+ const candidate = value as Record<string, unknown>;+ return (+ typeof candidate['title'] === 'string' &&+ typeof candidate['logline'] === 'string' &&+ typeof candidate['manuscriptWordCount'] === 'number' &&+ Array.isArray(candidate['characterNames'])+ );+}+
async function decodeProjectSearchIndex(
stored: StoredProjectSearchIndex | ProjectSearchIndex,
): Promise<{ record: ProjectSearchIndex; needsMigration: boolean }> {
const recordId = stored.projectId;
const context = { store: SECURE_STORE, recordId };
if ('payload' in stored) {
+ if (stored.schemaVersion !== RECORD_SCHEMA_VERSION) throw new SecureRecordCorruptError();
const decoded = await readSecureRecordPayload<ProjectSearchPayload>(stored.payload, context);
+ if (!isProjectSearchPayload(decoded.value)) throw new SecureRecordCorruptError();
Add the same guard after the legacy read at line 125, and add SecureRecordCorruptError to the import list at lines 12–19.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/crossProjectIndexService.ts` around lines 108 - 137, Update
decodeProjectSearchIndex to validate decoded payloads before spreading them,
matching the guard used by the sibling decoder in aiInferenceCacheService;
reject invalid shapes with SecureRecordCorruptError and compare
stored.schemaVersion against RECORD_SCHEMA_VERSION, including for the
legacy-read path. Ensure both payload branches fail before constructing
ProjectSearchIndex so enrichProjectIndex and migration cannot process or persist
corrupt records.
Add QNBS-v3 why-comments for the two new exported functions.
migrateProForgeHistoryForDisable and reEncryptProForgeHistory carry doc comments only. The project rule requires one single-line QNBS-v3 comment that states why each non-trivial change exists.
📝 Proposed change
/** Decrypt all history payloads to plaintext before encryption is disabled. */
+// QNBS-v3: Disable must convert envelopes before the passphrase verifier is removed.
export async function migrateProForgeHistoryForDisable(): Promise<void> {
/** Re-encrypt all history payloads during passphrase rotation. */
+// QNBS-v3: Rotation must rebind every envelope to the new key or the records become unreadable.
export async function reEncryptProForgeHistory(
As per coding guidelines: "Add one single-line QNBS-v3 why-comment for every non-trivial code change; never wrap it across physical lines."
Also applies to: 155-155
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/proForge/proForgeHistoryStore.ts` at line 132, Add a single-line
QNBS-v3 why-comment immediately before each exported function,
migrateProForgeHistoryForDisable and reEncryptProForgeHistory, explaining why
the function exists; keep the existing doc comments and ensure each why-comment
remains on one physical line.
Remove the redundant UTF-8 round trip in the legacy path.
Line 316 decodes bytes to text, and line 319 re-encodes text back to bytes for decodeSecureRecordValue. Pass bytes directly and decode to text only for the JSON.parse fallback. This avoids two extra full-payload conversions per legacy record and keeps the byte data authoritative.
♻️ Proposed refactor
try {
const bytes = await _svc.decryptBytes(key, blob);
- const text = new TextDecoder().decode(bytes);
try {
return {
- value: decodeSecureRecordValue(new TextEncoder().encode(text)) as T,+ value: decodeSecureRecordValue(bytes) as T,
needsMigration: true,
};
} catch {
- return { value: JSON.parse(text) as T, needsMigration: true };+ return {+ value: JSON.parse(new TextDecoder().decode(bytes)) as T,+ needsMigration: true,+ };
}
} catch {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/storage/storageEncryptionService.ts` around lines 314 - 324, Update
the legacy decryption flow in the method containing _svc.decryptBytes to pass
the original bytes directly to decodeSecureRecordValue, removing the
TextDecoder/TextEncoder round trip. Decode bytes to text only inside the
JSON.parse fallback, while preserving the existing needsMigration and return
behavior.
Both branches of the ternary read the same id property. The else branch also dereferences .id on a value that the condition already established is not an object, which throws for null or a primitive. Read the property once through a narrow guard.
♻️ Proposed refactor
- const recordId =- typeof stored === 'object' && stored !== null && 'id' in stored- ? String((stored as { id: string }).id)- : String((stored as SceneRevision).id);+ // QNBS-v3: One read path avoids dereferencing `.id` on a non-object record.+ if (typeof stored !== 'object' || stored === null || !('id' in stored)) {+ throw new SecureRecordCorruptError();+ }+ const recordId = String((stored as { id: string }).id);
SecureRecordCorruptError then needs adding to the import list at lines 5–11.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/sceneRevisionService.ts` around lines 94 - 97, Update the recordId
derivation in the scene revision flow to read stored.id once through a narrow
object-and-id guard, avoiding the unsafe else-branch dereference for null or
primitive values. Preserve the existing string conversion and add
SecureRecordCorruptError to the imports so invalid stored records are handled
through the established corruption error path.
Encode binary payloads as base64 instead of number[].
Array.from(bytes) plus JSON.stringify expands each byte to 2–4 JSON characters plus a separator. A 10 MB Blob or embedding buffer therefore produces roughly 30–40 MB of intermediate string and byte data before AES-GCM encryption. This runs on the main thread for every ProForge history write and every LoRA dataset write.
Base64 keeps the expansion at about 1.37x and encodes and decodes faster.
Then update the u8 and blob encode and decode branches to use toBase64 and fromBase64.
Note that this changes the on-disk node shape. Keep CODEC_VERSION = 1 only if no build has shipped these records; otherwise bump the version and accept both shapes on read.
Also applies to: 38-41
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/storage/secureRecordCodec.ts` at line 25, Update the binary handling
in the secure record codec’s encode/decode branches for the visible Uint8Array
path and the related u8/blob branches to serialize bytes with toBase64 and
restore them with fromBase64 instead of number arrays. Preserve the existing
type tags and ensure decoding accepts the appropriate stored shape; determine
whether CODEC_VERSION has shipped, keeping it at 1 only for unreleased records
or bumping it and supporting both legacy and base64 shapes when compatibility is
required.
Extract the secure-store labels into module constants.
The three store labels appear as inline string literals in at least nine places: 'lora-adapter-meta' at lines 130, 139 and 715, 'lora-dataset' at lines 435, 444 and 719, and 'lora-training-run' at lines 567, 576 and 723.
These strings form the AES-GCM additional authenticated data. A typo in one site produces a different AAD, so the affected records fail to decrypt and surface as SecureRecordCorruptError. Every sibling service in this change set defines a single SECURE_STORE constant, for example services/proForge/proForgeMemoryBank.ts line 17.
♻️ Proposed refactor
+// QNBS-v3: Single source for AAD store labels — a typo would make records undecryptable.+const SECURE_STORE_META = 'lora-adapter-meta';+const SECURE_STORE_DATASET = 'lora-dataset';+const SECURE_STORE_RUN = 'lora-training-run';
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/loraAdapterService.ts` around lines 129 - 132, Define module-level
constants for the three secure-store labels in services/loraAdapterService.ts,
following the SECURE_STORE pattern used by sibling services. Replace every
inline 'lora-adapter-meta', 'lora-dataset', and 'lora-training-run' value across
the affected payload and encryption/decryption call sites with the corresponding
constant, preserving each label’s exact value.
Simplify the store loop and replace the branch chain with a lookup table.
Line 662 iterates an array of single-element tuples and destructures each one. A plain array of store names is equivalent and clearer. Lines 675–705 then dispatch on storeName through an if / else if / else chain and end with a double cast at line 707.
A per-store handler map removes both the chain and the cast, and it keeps each record shape next to its own decoder.
♻️ Proposed refactor
- for (const [storeName] of [- [META_STORE] as const,- [DATASETS_STORE] as const,- [RUNS_STORE] as const,- ]) {+ // QNBS-v3: Handler map keeps each store's plaintext shape beside its decoder.+ const toPlaintext: Record<string, (stored: unknown) => Promise<Record<string, unknown>>> = {+ [META_STORE]: async (stored) => { /* adapter-meta mapping */ },+ [DATASETS_STORE]: async (stored) => { /* dataset mapping */ },+ [RUNS_STORE]: async (stored) => { /* training-run mapping */ },+ };+ for (const storeName of [META_STORE, DATASETS_STORE, RUNS_STORE]) {
Each loop body then becomes plaintext.push(await toPlaintext[storeName]!(stored));, and putRecords receives a correctly typed array.
As per coding guidelines: "Prefer lookup tables such as Partial<Record<Key, Fn>> over long if/else if dispatch chains".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/loraAdapterService.ts` around lines 662 - 708, Simplify the store
iteration in the export flow by using a plain array of store names instead of
single-element tuples. Replace the storeName dispatch chain with a typed
per-store handler lookup table, colocating each decoder and plaintext record
shape; have the loop invoke the handler for each stored record and push its
result. Pass the resulting correctly typed records to putRecords without the
double cast.
Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@hooks/useSettingsView.ts`:
- Line 392: Add a single physical-line `// QNBS-v3: ...` rationale immediately
above the `reEncryptAllSecondaryStores` call, explaining why the secondary
stores must be re-encrypted during the key migration and matching the adjacent
rationale’s context.
- Around line 389-393: Block passphrase rotation and disablement until migration
recovery is durable: update the useSettingsView flows around
rotateIdbPassphrase, reEncryptAllSecondaryStores,
decryptAllSecondaryStoresToPlaintext, and clearIdbPassphrase, together with
their service APIs, to either implement the documented persistent
journal/checkpoint and resume or rollback behavior while preserving verifier/key
recovery, or reject these operations consistently in both UI and services. Add
coverage for interruption/restart, mixed-key or mixed-encryption state, verifier
failure, and disable-recovery scenarios.
- Around line 389-393: Update the rotateIdbPassphrase callback in
useSettingsView to migrate every worldscript-data-db store, including Codex,
RAG, image, and binder records, by re-encrypting them with the new key and
performing the required plaintext migration. Preserve the existing verifier and
old key state until all migrations complete successfully, then allow
clearIdbPassphrase or key-state updates to proceed.
In `@services/ai/aiInferenceCacheService.ts`:
- Around line 324-332: Replace the ad-hoc ciphertext checks in the rotation
helpers with the exported isSecureRecordEnvelope guard. In
services/ai/aiInferenceCacheService.ts (324-332), import and use
isSecureRecordEnvelope and remove the SecureRecordEnvelope cast; make the same
changes in services/proForge/proForgeHistoryStore.ts (167-177) and
services/proForge/proForgeMemoryBank.ts (467-488), removing their redundant
casts while preserving the existing re-encryption and plaintext paths.
- Around line 286-298: In the loop processing stored records, validate the
decoded payload’s result before appending to plaintext, matching the shape check
used by decodeEntry. Ensure non-string results raise SecureRecordCorruptError
instead of writing `{ result: undefined }`, while preserving the existing
valid-record migration behavior.
- Around line 267-273: Update openInferenceCacheDb to handle newly created
databases by creating IDB_STORE during onupgradeneeded, and ensure opened
connections are closed after use. In migrateAiInferenceCacheForDisable and
reEncryptAiInferenceCache, wrap database operations in finally blocks that close
the returned IDBDatabase, while preserving the existing lifecycle behavior when
the store is absent.
In `@services/proForge/proForgeHistoryStore.ts`:
- Around line 133-153: Update migrateProForgeHistoryForDisable to decode each
stored.payload directly instead of calling loadRunHistory, avoiding per-record
reads and re-encryption; then write all transformed records through a single
transaction rather than calling putHistory separately for each record. Preserve
each record’s projectId and plaintext schemaVersion while committing the
complete migration atomically.
In `@services/storage/secondaryStorageLifecycle.ts`:
- Around line 26-46: The aggregate operations
decryptAllSecondaryStoresToPlaintext and reEncryptAllSecondaryStores are
non-atomic and cannot recover after partial failure. Before allowing passphrase
rotation or encryption disable, either implement a durable IndexedDB journal
recording each store’s phase and cursor, replaying it on startup, and retaining
the old verifier until completion, or block both flows in the settings UI and
service API until that journal exists. Add coverage for interruption/restart,
wrong passphrases, mixed-key records, verifier-replacement failure, and disable
recovery.
In `@services/storage/secureRecordCodec.ts`:
- Around line 30-35: Update encodeNode to represent undefined with an explicit
undef node instead of dropping object properties or stringifying array elements,
and reject unsupported values such as bigint, functions, and symbols rather than
converting them to strings. Preserve supported primitive encodings, but validate
numeric values so NaN and other non-JSON-safe numbers are not encoded as num
nodes. Add the matching undef branch in decodeNode to return undefined.
---
Outside diff comments:
In `@hooks/useSettingsView.ts`:
- Around line 389-404: Update handlePassphraseConfirm to wrap the complete
passphrase lifecycle operation, including rotation, unlock, and disable flows,
in try/catch. Keep the modal open on failure, show a brief actionable translated
error, and move setEncryptionReady and success-toast updates so they execute
only after the corresponding async operations complete successfully.
In `@services/ai/aiInferenceCacheService.ts`:
- Around line 182-192: Update the cache read and write paths in
getCachedInference and setCachedInference so SecureRecordLockedError and
SecureRecordCorruptError are treated as cache misses or ignored persistence
failures rather than propagated to AI callers. Catch decodeEntry and
persist/encode failures, log them through services/logger.ts, resolve the read
with null, and keep writes best effort; remove the now-unused reject parameter
from the getCachedInference promise executor.
---
Duplicate comments:
In `@services/crossProjectIndexService.ts`:
- Around line 108-137: Update decodeProjectSearchIndex to validate decoded
payloads before spreading them, matching the guard used by the sibling decoder
in aiInferenceCacheService; reject invalid shapes with SecureRecordCorruptError
and compare stored.schemaVersion against RECORD_SCHEMA_VERSION, including for
the legacy-read path. Ensure both payload branches fail before constructing
ProjectSearchIndex so enrichProjectIndex and migration cannot process or persist
corrupt records.
---
Nitpick comments:
In `@services/loraAdapterService.ts`:
- Around line 129-132: Define module-level constants for the three secure-store
labels in services/loraAdapterService.ts, following the SECURE_STORE pattern
used by sibling services. Replace every inline 'lora-adapter-meta',
'lora-dataset', and 'lora-training-run' value across the affected payload and
encryption/decryption call sites with the corresponding constant, preserving
each label’s exact value.
- Around line 662-708: Simplify the store iteration in the export flow by using
a plain array of store names instead of single-element tuples. Replace the
storeName dispatch chain with a typed per-store handler lookup table, colocating
each decoder and plaintext record shape; have the loop invoke the handler for
each stored record and push its result. Pass the resulting correctly typed
records to putRecords without the double cast.
In `@services/proForge/proForgeHistoryStore.ts`:
- Line 132: Add a single-line QNBS-v3 why-comment immediately before each
exported function, migrateProForgeHistoryForDisable and
reEncryptProForgeHistory, explaining why the function exists; keep the existing
doc comments and ensure each why-comment remains on one physical line.
In `@services/sceneRevisionService.ts`:
- Around line 94-97: Update the recordId derivation in the scene revision flow
to read stored.id once through a narrow object-and-id guard, avoiding the unsafe
else-branch dereference for null or primitive values. Preserve the existing
string conversion and add SecureRecordCorruptError to the imports so invalid
stored records are handled through the established corruption error path.
In `@services/storage/secureRecordCodec.ts`:
- Line 25: Update the binary handling in the secure record codec’s encode/decode
branches for the visible Uint8Array path and the related u8/blob branches to
serialize bytes with toBase64 and restore them with fromBase64 instead of number
arrays. Preserve the existing type tags and ensure decoding accepts the
appropriate stored shape; determine whether CODEC_VERSION has shipped, keeping
it at 1 only for unreleased records or bumping it and supporting both legacy and
base64 shapes when compatibility is required.
In `@services/storage/storageEncryptionService.ts`:
- Around line 314-324: Update the legacy decryption flow in the method
containing _svc.decryptBytes to pass the original bytes directly to
decodeSecureRecordValue, removing the TextDecoder/TextEncoder round trip. Decode
bytes to text only inside the JSON.parse fallback, while preserving the existing
needsMigration and return behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
Push a commit to this branch (recommended)
Create a new PR with the fixes
ℹ️ Review info⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0c03ea39-ddfc-4949-8236-b1049197f114
📥 Commits
Reviewing files that changed from the base of the PR and between 0898f6a and 3a18c9d.
The reason will be displayed to describe this comment to others. Learn more.
⚠️ Potential issue | 🟠 Major
Block both lifecycle operations until migration recovery is durable.
Line 392 and Lines 403-404 execute independent IndexedDB migrations sequentially. A crash or failed store can leave rotation with mixed oldKey/newKey envelopes or disablement with mixed plaintext/encrypted records. No durable checkpoint or resume contract appears here.
Implement the documented journal and preserve verifier/key recovery, or reject passphrase change and disablement in both the UI and service APIs. Add interruption/restart, mixed-key, verifier-failure, and disable-recovery tests.
This is the same unresolved release-blocking finding from the previous review and the PR objective.
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@hooks/useSettingsView.ts` around lines 389 - 393, Block passphrase rotation
and disablement until migration recovery is durable: update the useSettingsView
flows around rotateIdbPassphrase, reEncryptAllSecondaryStores,
decryptAllSecondaryStoresToPlaintext, and clearIdbPassphrase, together with
their service APIs, to either implement the documented persistent
journal/checkpoint and resume or rollback behavior while preserving verifier/key
recovery, or reject these operations consistently in both UI and services. Add
coverage for interruption/restart, mixed-key or mixed-encryption state, verifier
failure, and disable-recovery scenarios.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
#!/bin/bashset -euo pipefail
printf'%s\n''--- data-store classes and encryption lifecycle ---'
ast-grep outline services/storage/idbAssetStore.ts --view compact ||true
ast-grep outline services/storage/idbCodexStore.ts --view compact ||true
rg -n -C 12 \
'class IdbAssetStore|class IdbCodexStore|reEncrypt|idbEncrypt|idbReadSecure|isEncryptedBlob|worldscript-data-db|openDatabase' \
services/storage/idbAssetStore.ts services/storage/idbCodexStore.ts services/storage/idbProjectStore.ts \
services/storage/storageEncryptionService.ts services/dbConstants.ts
printf'%s\n''--- all primary-store callers and public exports ---'
rg -n -C 8 \
'reEncryptAllAppData|reEncryptAllSnapshots|IdbAssetStore|IdbCodexStore|IdbProjectStore|IdbSnapshotStore|dbService' \
services/storage hooks/useSettingsView.ts
printf'%s\n''--- rotation and disable control flow ---'
sed -n '470,545p' services/storage/storageEncryptionService.ts
sed -n '370,412p' hooks/useSettingsView.ts
Repository: qnbs/WorldScript-Studio
Length of output: 50379
🏁 Script executed:
#!/bin/bashset -euo pipefail
python3 - <<'PY'from pathlib import Pathpaths = [ Path("services/storage/idbAssetStore.ts"), Path("services/storage/idbCodexStore.ts"), Path("services/storage/idbProjectStore.ts"), Path("services/storage/idbSnapshotStore.ts"), Path("services/storage/storageEncryptionService.ts"), Path("hooks/useSettingsView.ts"), Path("docs/IDB-ENCRYPTION.md"),]needles = ( "worldscript-data-db", "idbEncrypt", "idbReadSecure", "isEncryptedBlob", "rotateIdbPassphrase", "clearIdbPassphrase", "reEncrypt", "migrate",)for path in paths: print(f"--- {path} ---") lines = path.read_text().splitlines() for i, line in enumerate(lines, 1): if any(n in line for n in needles): lo, hi = max(1, i - 5), min(len(lines), i + 9) print(f"[{lo}-{hi}]") for j in range(lo, hi + 1): print(f"{j}: {lines[j-1]}")PY
Repository: qnbs/WorldScript-Studio
Length of output: 50381
Migrate all worldscript-data-db stores before changing the key state.
The callback omits Codex, RAG, image, and binder records. Rotation leaves these records encrypted with the old key. Disable leaves them unreadable after clearIdbPassphrase(). Add re-encryption and plaintext migration for these stores, and retain the existing verifier and key until migration succeeds.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@hooks/useSettingsView.ts` around lines 389 - 393, Update the
rotateIdbPassphrase callback in useSettingsView to migrate every
worldscript-data-db store, including Codex, RAG, image, and binder records, by
re-encrypting them with the new key and performing the required plaintext
migration. Preserve the existing verifier and old key state until all migrations
complete successfully, then allow clearIdbPassphrase or key-state updates to
proceed.
Line 392 adds a non-trivial secondary-store migration. The adjacent rationale spans Lines 387-388. Add one physical-line // QNBS-v3: ... comment immediately above this call.
As per coding guidelines, every non-trivial TypeScript change requires one single-line QNBS-v3 why-comment.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@hooks/useSettingsView.ts` at line 392, Add a single physical-line `//
QNBS-v3: ...` rationale immediately above the `reEncryptAllSecondaryStores`
call, explaining why the secondary stores must be re-encrypted during the key
migration and matching the adjacent rationale’s context.
openInferenceCacheDb can open a database without the object store.
The helper calls indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION) with no onupgradeneeded handler. On a profile where the cache database does not yet exist, the open succeeds and creates an empty database. The following db.transaction(IDB_STORE, ...) then throws NotFoundError.
Both migrateAiInferenceCacheForDisable and reEncryptAiInferenceCache use this helper, and both run inside the aggregate lifecycle chain. A single NotFoundError aborts the whole rotation or disable sequence.
The helper also never closes the connection, so each call leaks a database handle.
🐛 Proposed fix
async function openInferenceCacheDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(IDB_DB_NAME, IDB_DB_VERSION);
+ // QNBS-v3: Create the store on first open so lifecycle migrations cannot hit NotFoundError.+ request.onupgradeneeded = () => {+ const db = request.result;+ if (!db.objectStoreNames.contains(IDB_STORE)) {+ const store = db.createObjectStore(IDB_STORE, { keyPath: 'key' });+ store.createIndex('timestamp', 'timestamp', { unique: false });+ }+ };
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Then close the handle in a finally block in each caller, or guard with if (!db.objectStoreNames.contains(IDB_STORE)) return;.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/ai/aiInferenceCacheService.ts` around lines 267 - 273, Update
openInferenceCacheDb to handle newly created databases by creating IDB_STORE
during onupgradeneeded, and ensure opened connections are closed after use. In
migrateAiInferenceCacheForDisable and reEncryptAiInferenceCache, wrap database
operations in finally blocks that close the returned IDBDatabase, while
preserving the existing lifecycle behavior when the store is absent.
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Validate result before writing the plaintext payload.
Line 296 reads decoded.value.result with no shape check. decodeEntry performs that check at line 139 and throws SecureRecordCorruptError when result is not a string. This loop does not, so a legacy or malformed record writes { result: undefined } back to IndexedDB as plaintext. The next read then reports corruption for a record that this migration produced.
🐛 Proposed fix
const decoded = await readSecureRecordPayload<CachePayload>(rawPayload, {
store: SECURE_STORE,
recordId,
});
+ // QNBS-v3: Reject malformed payloads here so disable cannot persist an invalid plaintext record.+ if (typeof decoded.value?.result !== 'string') throw new SecureRecordCorruptError();
plaintext.push({
📝 Committable suggestion
‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/ai/aiInferenceCacheService.ts` around lines 286 - 298, In the loop
processing stored records, validate the decoded payload’s result before
appending to plaintext, matching the shape check used by decodeEntry. Ensure
non-string results raise SecureRecordCorruptError instead of writing `{ result:
undefined }`, while preserving the existing valid-record migration behavior.
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Decode records in place instead of calling loadRunHistory per record.
Line 143 calls loadRunHistory(stored.projectId) for a record that getAll already returned. Three effects follow:
Each iteration performs an extra get transaction on a record already held in memory.
loadRunHistory re-encrypts the record when decoded.needsMigration is true, at lines 119–121. The loop then writes the same record back as plaintext. During a disable operation this writes an encrypted record first and a plaintext record second. If the process stops between the two writes, the store holds a newly encrypted record, which is the opposite of the intent.
Lines 150–152 write each record in a separate transaction.
Decode stored.payload directly and commit the plaintext records in one transaction.
♻️ Proposed refactor
const plaintext: HistoryRecord[] = [];
for (const stored of raw) {
- const runs = await loadRunHistory(stored.projectId);+ // QNBS-v3: Decode in place so disable never re-encrypts a record on its way to plaintext.+ const rawPayload = 'payload' in stored ? stored.payload : { runs: stored.runs };+ const decoded = await readSecureRecordPayload<{ runs: PipelineRun[] }>(rawPayload, {+ store: SECURE_STORE,+ recordId: stored.projectId,+ });
plaintext.push({
projectId: stored.projectId,
schemaVersion: RECORD_SCHEMA_VERSION,
- payload: { runs },+ payload: { runs: decoded.value.runs },
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/proForge/proForgeHistoryStore.ts` around lines 133 - 153, Update
migrateProForgeHistoryForDisable to decode each stored.payload directly instead
of calling loadRunHistory, avoiding per-record reads and re-encryption; then
write all transformed records through a single transaction rather than calling
putHistory separately for each record. Preserve each record’s projectId and
plaintext schemaVersion while committing the complete migration atomically.
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift
Both aggregate operations are non-atomic and non-resumable.
Each function awaits six independent per-database migrations in sequence. If any step rejects, the earlier stores are already converted and the later stores are not. Two failure modes follow:
Rotation: rotateIdbPassphrase replaces the passphrase verifier through setupIdbEncryption before it invokes the reEncrypt callback. If reEncryptProForgeMemory rejects, scene-revisions and inference-cache already use the new key while proforge-history, projects-index and the LoRA stores still use the old key. The application can no longer derive the old key, so those records stay unreadable.
Disable: if a migrate*ForDisable call rejects after earlier stores are plaintext, the remaining stores keep their envelopes. If clearIdbPassphrase then removes the sentinel, those envelopes are unrecoverable.
No progress record exists, so a restart cannot resume or roll back.
This matches the release-blocking item in the PR description. Choose one of the two documented resolutions before this ships:
Add a durable journal that records { store, phase, cursor } in IndexedDB before each step, replay it on startup, and keep the old-key verifier until every store reports completion.
Block passphrase change and encryption disable in both the settings UI and the service API until that journal exists.
Add tests for interruption and restart, wrong passphrase, mixed old and new records, failure before verifier replacement, and disable recovery.
I can also help scope the journal design if you want to proceed with option 1.
Run the following script to check how the settings flow sequences these calls relative to the sentinel writes:
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/storage/secondaryStorageLifecycle.ts` around lines 26 - 46, The
aggregate operations decryptAllSecondaryStoresToPlaintext and
reEncryptAllSecondaryStores are non-atomic and cannot recover after partial
failure. Before allowing passphrase rotation or encryption disable, either
implement a durable IndexedDB journal recording each store’s phase and cursor,
replaying it on startup, and retaining the old verifier until completion, or
block both flows in the settings UI and service API until that journal exists.
Add coverage for interruption/restart, wrong passphrases, mixed-key records,
verifier-replacement failure, and disable recovery.
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
encodeNode silently converts unsupported values to strings.
Line 35 turns undefined, bigint, functions, and symbols into { k: 'str', v: String(value) }. An undefined element inside an array therefore decodes as the string "undefined". Line 31 drops undefined object properties, so the two paths behave differently.
{ k: 'num', v: NaN } has a related problem: JSON.stringify writes null, and decodeNode then returns null where a number is expected.
Encode undefined explicitly and reject types the codec cannot represent.
const encodeNode = (value: unknown): EncodedNode => {
if (value === null) return { k: 'null' };
+ if (value === undefined) return { k: 'undef' };
if (typeof value === 'boolean') return { k: 'bool', v: value };
- if (typeof value === 'number') return { k: 'num', v: value };+ if (typeof value === 'number') {+ if (!Number.isFinite(value)) throw new Error('Secure-record codec: non-finite number');+ return { k: 'num', v: value };+ }
- return { k: 'str', v: String(value) };+ throw new Error(`Secure-record codec: unsupported value type ${typeof value}`);
};
Add the matching case 'undef': return undefined; branch to decodeNode.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@services/storage/secureRecordCodec.ts` around lines 30 - 35, Update
encodeNode to represent undefined with an explicit undef node instead of
dropping object properties or stringifying array elements, and reject
unsupported values such as bigint, functions, and symbols rather than converting
them to strings. Preserve supported primitive encodings, but validate numeric
values so NaN and other non-JSON-safe numbers are not encoded as num nodes. Add
the matching undef branch in decodeNode to return undefined.
The reason will be displayed to describe this comment to others. Learn more.
Migration ignores legacy record shapes
High Severity
Bulk disable and passphrase-rotation helpers decrypt only stored.payload, but several secondary stores still hold pre-migration flat records whose sensitive fields sit on the root object. Those paths feed undefined into readSecureRecordPayload / reEncryptPayload, yielding empty inner payloads or re-encrypted garbage and permanent loss of scene revisions, memory entries, cross-project metadata, LoRA records, and legacy inference-cache rows.
Add unit tests for decrypt/re-encrypt lifecycle helpers (codecov patch).
Guard migration IDB reads/writes when object stores are absent — fixes
disable/rotate on installs that never created optional secondary DBs.
Document JS-0058 dashboard revert; add maintainer thread-resolve script.
The reason will be displayed to describe this comment to others. Learn more.
Migration ignores legacy record shapes
High Severity
Bulk disable and passphrase-rotation helpers only decrypt or re-wrap stored.payload, unlike the feature services that still accept legacy top-level plaintext fields and inference-cache result rows. Legacy records can lose content, gain a useless payload, or keep manuscript text in plaintext after rotation or disable.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
User description
Summary
Verification
pnpm run lintpnpm run typecheckpnpm run i18n:check(19 locales, 2,869 keys)pnpm run parity:checkpnpm run docs:checkpnpm run suppressions:check(52/52)pnpm run token:audit(160/160)Security boundary
Large LoRA weight blobs remain outside the manuscript-data guarantee. DuckDB structural analytics metadata remains an accepted plaintext boundary; literal
codex_mentions.excerptencryption is unchanged. Durable resumable passphrase rotation across independent databases is intentionally deferred to the next isolated security change.Note
High Risk
Touches encryption, key lifecycle, and many independent IndexedDB databases; mis-handling locked state, rotation, or disable could strand or expose sensitive manuscript-related data.
Overview
Extends at-rest encryption beyond primary project blobs to content-bearing secondary IndexedDB databases using a shared versioned AES-256-GCM envelope (
prepareSecureRecordPayload/readSecureRecordPayload), record-bound AAD, and a structured codec that preserves binary payloads (e.g. Blobs).Encrypted surfaces include scene revisions, AI inference cache, ProForge memory and run history, cross-project search metadata/embeddings, and LoRA adapter metadata, datasets, and training runs (routing/index fields stay plaintext; large LoRA weight blobs remain outside the guarantee). Reads/writes fail closed when encryption is configured but locked (
SecureRecordLockedError/SecureRecordCorruptError); legacy plaintext migrates lazily after unlock. Settings passphrase change/disable now runs bulk re-encrypt or decrypt-to-plaintext across secondary stores viasecondaryStorageMigration.ts.Docs and release notes are updated to describe the new boundary and to state that cross-database passphrase rotation is not atomic—a durable resumable journal remains follow-up work. DeepSource gets
cyclomatic_complexity_threshold = "critical"; focused Vitest coverage and a maintainer script for resolving DeepSource review threads are added.Reviewed by Cursor Bugbot for commit 27177ce. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
Security
Documentation
CodeAnt-AI Description
Encrypt sensitive secondary IndexedDB data and fail closed while storage is locked
What Changed
Impact
✅ Sensitive secondary data stays unreadable in extracted IndexedDB files✅ No plaintext fallback while storage is locked✅ Legacy records migrate without requiring a manual export💡 Usage Guide
Checking Your Pull Request
Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.
Talking to CodeAnt AI
Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:
This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.
Example
Preserve Org Learnings with CodeAnt
You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:
This helps CodeAnt AI learn and adapt to your team's coding style and standards.
Example
Retrigger review
Ask CodeAnt AI to review the PR again, by typing:
Check Your Repository Health
To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.